#!/usr/bin/env python
"""
usage: %prog [FILES|DIRECTORIES] 
       %prog -c [FILES]
       %prog <revision> FILE ...
       %prog <revision1> <revision2> FILE ... 

Display file differences with the current or a previous CVS version.
Homepage: http://cvsxxdiff.googlecode.com/
"""

# ChangeLog:
#
# version 0.1: first version realeased to the public
# version 0.2: runs on specific repository directories.
# version 0.3: prints CVS log messages
# version 0.3.1: fix bug in bi_bj mode (print of cvs logs)
# version 0.3.2: better separation of logs and masseges for eatch file in
#                stdout.
#              : Fix utility, when the file does not yet exist in CVS.
# version 0.4: add option to unmerge conflicts from source files. ( -c )
# version 0.7: color output
# version 0.8: color: color the entire header of the cvs log.
#            : catch exceptions when files are not found in trunc.
#            : add option ( - ) to read files from stdin.
# version 0.8.1: fix 'files to diff' output.
#              : change colors in 'cvs log' output to be the same with vim.
# version 1.0.0: Reformat argumet parsing
#              : provide an indication of the number of files processed / 
#                remaining
#              : Fix. When a file exists more tha once in the repository, 
#                cvsxxdiff finds the correct one.
#              : Allow passing options to xxdiff ( with -x arg ).
# version 1.1.0: make cvsxxdiff usable with cvs branches.
# version 1.1.1: path seperator taken from os.path.sep. TODO: remove /dev/null
#                usage
#                Fix in -b mode when in branch.
# version 1.1.2: All diff operations read from temporary files.
#
# version 0.1.5: fix argument parsing to handle: -r1.1 -r1.2 file1 -r1.1 -r1.2
#                file2
#
#





__version__ = '0.1.5'
__author__ = 'Michalis Giannakidis <mgiannakidis@gmail.com>'

import os
import sys
import re
import tempfile
import optparse

#===============================================================================
XXDIFF_EXE = 'xxdiff'

PID = str(os.getpid())
T_File1 = ''
T_File2 = ''

#===============================================================================
def cprint(field, color = 'white'):
	"""Return the 'field' in collored terminal form"""

	term_colors = {
		'black':30,
		'red':31,
		'green':32,
		'yellow':33,
		'blue':34,
		'magenta':35,
		'cyan':36,
		'white':37, 
	}
	field = '[01;%dm%s[00m' % ( term_colors[color], str(field) )
	return field

#===============================================================================
def print_log(seq):
	"""Print eatch item of seq seperatly, but all in the same line"""
	if len(seq):
		print cprint(seq[0], 'magenta')
		print cprint(seq[1], 'cyan')
		print '\n'.join(map(str, seq[2:]))


def run_diff_cmd(cmd):
	"""
	run the diff command. Use it to invoke xxdiff (although the command
	may also contain calls to cvs etc.)
	"""
	os.popen(cmd)

#===============================================================================
def mkheader(file_to_diff, ith, total):
	header = '>'+file_to_diff+'<'
	header = header.center(80)
	header = header.replace(' ', '-')
	progress = '[%d/%d] ' % ( ith, total )
	header =  progress+header[len(progress):]
	header = cprint(header, 'green')
	return header

#===============================================================================
def remove_files(files):
	"""Remove files, if they exist"""

	map(os.unlink, filter(os.path.isfile, files))

#===============================================================================
def get_cvsroot_path(dir):
	"""
	Extract the path from the CVS/Root file
	and the module name from CVS/Repository
	"""

	crt, crp = None, None
	if not dir:
		dir = ''

	cvsroot = open( os.path.join(dir, 'CVS', 'Root') ).readline()
	cvsroot = cvsroot.rstrip()
	if cvsroot[-1] == os.path.sep: cvsroot = cvsroot[:-1]

	i = 0
	while cvsroot[i]:
		if cvsroot[i] == os.path.sep:
			crt =  cvsroot[i:]
			break
		i = i + 1

	cvsrepos = open(os.path.join(dir, 'CVS', 'Repository' ) ).readline()
	crp = cvsrepos.rstrip()

	return crt, crp

#===============================================================================
def collect_unupdated_files(diff_files, diff_directories, resolve_conflicts):
	"""Collect all files that are not updated (compared to the repository)"""


	dir_string = ' '.join(diff_directories)
	sin, sout, serr = os.popen3('cvs -q status '+dir_string)
	match_status = re.compile( 'File:\s*([^\s]+)\s*Status: (.*)' )

	try:
		CvsRoot, CvsRepository = get_cvsroot_path(None)
	except IOError:
		raise SystemExit('Error: You are not in a CVS managed \
directory.\nSee cvsxxdiff -h for details')
	# transform a file 'Repository revision' to a local filename:
	# eg: /mnt/raid_disk/ansa10_cvsroot/ansa10/argiris/a_rbody.c,v -> 
	# argiris/a_rbody.c
	abs_file_prefix = os.path.sep.join([CvsRoot, CvsRepository, ''])
	match_repos = re.compile( 
		'Repository revision:\s[\d\.]*\s*'+abs_file_prefix+'(.*),v'
				)

	intrested_in = None
	for l in sout:
		# match Status:, then Repository revision
		m = match_status.search(l)
		if  m:
			if intrested_in:
				print cprint(intrested_in+
				    ': Cannot retrieve status information', \
				    'red')
				intrested_in = None
				
			if resolve_conflicts:
				if m.group(2) != 'File had conflicts on merge': 
					continue
			else:
				if m.group(2) == 'Up-to-date': 
					continue

			intrested_in = m.group(1)
			continue

		mf = match_repos.search(l)
		if mf and intrested_in:

			intrested_in = None

			rfile = mf.group(1)
			diff_files.append( rfile )

	sin.close()
	sout.close()
	serr.close()


#===============================================================================
def get_local_trunk_version(filename):
	"""
	For filename, get the current revision number (of the local instance)"""

	d, f = os.path.split(filename)
	entries = os.path.join(d, 'CVS', 'Entries')

	try:
		lines = open(entries).readlines()
	except IOError:
		return ''

	match_file = re.compile( '/'+f+'/([0-9\.]*)')
	for l in lines:
		m = match_file.search(l)
		if  m:
			return m.group(1)
	return '1.1'

#===============================================================================
def get_repository_revision(filename):
	"""Get the Repository revision of a file"""

	lines = os.popen('cvs -q status '+filename+' 2>&1').readlines()
	match_repository_rev = re.compile( 'Repository revision:\s([\d\.]*)' )

	for l in lines:
		m = match_repository_rev.search(l)
		if  m:
			return m.group(1)
	return '1.1'


#===============================================================================
def get_revision_before(rev, back):
	"""Do math arithmetics to get a revision as 'back' versions before"""

	r = rev.split('.')

	r[-1] = str(int(r[-1]) - back)

	# we have reached the start of revision history... 
	# (propably a branch start)
	lr = int(r[-1])
	if lr <= 0:
		r = r[0:-2]
		if lr < 0:
			r[-1] = str(int(r[-1]) + lr)

	r = '.'.join(r)

	return r
		
#===============================================================================
def get_previous_revision(filename, back):
	"""
	For filename, get its previous revision number (compared to the local 
	version)
	"""

	r = get_local_trunk_version(filename)
	r = get_revision_before(r, back)

	return r

#===============================================================================
def get_revisions_between(r1, r2):
	"""Return a list with the CVS version strings between to releases."""

	rev = []

	l_r1 = r1.split('.')
	l_r2 = r2.split('.')
	min_common = min(len(l_r1), len(l_r2))
	try:
		# we could be on the start of a branch...
		# or comparing unequal things for some reason!

		beg = int(l_r1[min_common - 1])
		end = int(l_r2[min_common - 1])
	except ValueError:
		print cprint('Cannot retrieve revision information', 'red')
		return rev

	invert_beg_end = False
	if end < beg:
		invert_beg_end = True
	elif end == beg:
		if len(r1) > len(r2):
			invert_beg_end = True

	if invert_beg_end:
		r = r1
		r1 = r2
		r2 = r

	rev.append(r2)
	while True:
		if rev[0] == r1:
			break
		r = get_revision_before(rev[0], 1)
		rev.insert(0, r)

	if invert_beg_end:
		rev.reverse()

	return rev

#===============================================================================
def get_revision_log(filename, rev):
	"""Get the CVS log of a filename given a revision"""

	log = []
	lines = os.popen('cvs log -r'+rev+' '+filename+' 2>&1').readlines()
	match_log_start = re.compile( '^----------------------------' )
	start_log = False
	for l in lines:
		if start_log == 0:
			m = match_log_start.search(l)
			if m:
				start_log = True
				continue
		if start_log:
			log.append(l.rstrip())

	return log

#===============================================================================
def cvsxxdiff_ri_rj(file, revision_args):

	v1_comment = ''
	v2_comment = ''
	
	if revision_args[0] == '-b':
		v1_comment = 'File as %d versions ago' % revision_args[1]
		revision_args[0] = '-r'
		revision_args[1] = get_previous_revision(file, revision_args[1])

	os.system('cvs up %s %s -p %s 2>/dev/null > %s' % \
		(revision_args[0], revision_args[1], file, T_File1) )
	diff_file1 = T_File1

	if revision_args[2] == '':
		v2_comment = 'local instance'
		revision_args[2] = '-r'
		revision_args[3] = get_local_trunk_version(file)
		diff_file2 = file
	else:
		if revision_args[2] == '-b':
			v2_comment = 'File as %d versions ago' % revision_args[3]
			revision_args[2] = '-r'
			revision_args[3] = get_previous_revision(file, revision_args[3])

		os.system('cvs up %s %s -p %s 2>/dev/null > %s' % \
			(revision_args[2], revision_args[3], file, T_File2) )
		diff_file2 = T_File2

	if revision_args[0] == '-r' and  revision_args[2] == '-r':
		revs = get_revisions_between( revision_args[1], revision_args[3])
		map(lambda r: print_log(get_revision_log( file, r)), revs)

	title1 = ' --title1 " %s ( %s ) %s" ' % \
			( file, revision_args[1], v1_comment )
	title2 = ' --title2 " %s ( %s ) %s" ' % \
			( file, revision_args[3], v2_comment )

	run_diff_cmd('%s %s %s %s %s' % \
		(XXDIFF_EXE, title1, title2, diff_file1, diff_file2) )
	remove_files([T_File1, T_File2])

#===============================================================================
def cvsxxdiff_rep(file, empty_args):

	vr = get_repository_revision(file)
	vl = get_local_trunk_version(file)
	revs = get_revisions_between( vr, vl )

	map(lambda r: print_log(get_revision_log( file, r)), revs)

	os.system('cvs up -p '+file+' 2> /dev/null > '+T_File1)

	title1 = ' --title1 "%s ( %s ) cvs instance" ' % (file, vr)
	title2 = ' --title2 "%s ( %s ) local instance" ' % (file, vl)
	run_diff_cmd('%s %s %s %s %s' % \
		(XXDIFF_EXE, T_File1, file, title1, title2) )
	remove_files([T_File1])

#===============================================================================
def cvsxxdiff_c(file, empty_args):

	vl = get_local_trunk_version(file)
	print_log( get_revision_log(file, vl))

	run_diff_cmd('%s --resource "Show.PaneMergedView: True" -U %s' % \
		(XXDIFF_EXE, file) )

#===============================================================================
def rev_list_parse(args):
	if len(args) < 3: 
		if len(args) > 0: print 'Missing argument.'
		return None

	l = []
	if args[2][:1] == '-':
		l.extend( args[0:5] )
		del args[0:5]
	else:
		l.extend( args[0:2] )
		l.extend( [ '', '' ])
		l.append( args[2] )
		del args[0:3]


	return l

#===============================================================================
def add_rev_argument(option, opt, value, parser):

	if opt == '-b':
		try:
			value = int(value)
		except ValueError:
			parser.error('option %s: invalid integer value: %s' % \
					(opt, value) )

	parser.values.revision_args.extend([opt, value])
	if parser.rargs and parser.rargs[0][:1] != '-':
		value = parser.rargs[0]
		if value[-1] == ',': #handle cvscommit output
			value = value[:-1]
		parser.values.revision_args.extend([value])
		del parser.rargs[0]

#===============================================================================
def cli():
	"""Command-line interface"""

	global XXDIFF_EXE

	parser = optparse.OptionParser(usage=__doc__.strip(), 
					version='%prog '+ __version__)

	parser.add_option('-x', '--xxdiff-args', action='store', 
		metavar='<xxdiff args>', dest='xxdiff_args', default='',
		help='arguments passed to xxdiff')
	parser.add_option('-c', '--conflict-resolve', action='store_true',
		dest='resolve_conflicts', 
		help='resolve conflicts in merged files')
	parser.add_option('--stdin', action='store_true', default=False,
		dest='read_stdin', help='read FILES or DIRECTORIES from stdin')


	group = optparse.OptionGroup(parser, 
			'Options for selecting revisions other than latest. '\
			'Only before a file')

	group.add_option('-b', action='callback',
		metavar='<revisions before>', dest='revision_args', default=[],
		callback=add_rev_argument, type='string',
		help='number of previous CVS versions to compare with')
	group.add_option('-r', action='callback', 
		metavar='<revision>', dest='revision_args', default=[], 
		callback=add_rev_argument, type='string',
		help='revision in CVS to compare with')
	group.add_option('-D', action='callback',  default=[], 
		metavar='<date>', dest='revision_args', 
		callback=add_rev_argument, type='string',
		help='date in CVS to compare with')

	parser.add_option_group(group)

	opts, args = parser.parse_args()

	if opts.resolve_conflicts and (opts.revision_args or opts.read_stdin):
		parser.error('Invalid option combination.')

	XXDIFF_EXE = XXDIFF_EXE + ' ' + opts.xxdiff_args

	if opts.read_stdin:
		lines = sys.stdin.readlines()
		for l in lines:
			args.extend( [w for w in l.rstrip().split() if len(w)] )
# Done with arguments.


	diff_files, diff_directories = [], []
	for arg in args:
		if os.path.isdir(arg):
			diff_directories.append(arg)
		else:
			diff_files.append(arg)

	if len(opts.revision_args) == 0 and \
	   ((opts.read_stdin == False and len(diff_files) == 0 ) or \
	    len(diff_directories) ):
		collect_unupdated_files(diff_files, diff_directories, opts.resolve_conflicts)


	diff_files_rev = []
	while True:
		l = rev_list_parse(opts.revision_args)
		if not l: break
		diff_files_rev.append(l)

 
	if len(diff_files) + len(diff_files_rev) > 1 :
		print '%d files to diff:' % (len(diff_files) + len(diff_files_rev))
		if diff_files_rev:
			print ' '.join([ x[4] for x in diff_files_rev] )
		if diff_files:
			print ' '.join(map(str, diff_files))


	try:
		global T_File1, T_File2
		T_File1 = os.path.join(tempfile.gettempdir(), 'file1.'+PID)
		T_File2 = os.path.join(tempfile.gettempdir(), 'file2.'+PID)

		tot = len(diff_files) + len(diff_files_rev)

		for i, diff_vers in enumerate(diff_files_rev):
			print mkheader(diff_vers[4], i+1, tot)
			cvsxxdiff_ri_rj(diff_vers[4], diff_vers)

			
		if opts.resolve_conflicts:
			(diff_func, diff_args) = cvsxxdiff_c, None
		else:
			(diff_func, diff_args) = cvsxxdiff_rep, None

		header_offset = 1 + len(diff_files_rev)
		for i, file in enumerate(diff_files):
			print mkheader(file, i+header_offset, tot)
			diff_func(file, diff_args)

	except KeyboardInterrupt:
		# clean up.
		remove_files([T_File1, T_File2])
		print 'CTRL-C'


#===============================================================================
if __name__ == '__main__': cli()
